Take-home Exercise 03

Author

Andrea Yeo

Published

March 22, 2025

Modified

March 28, 2025

Prototyping Modules for Visual Analytics Shiny Application

1. The task

For this take-home exercise, each team member have to choose one module from our proposed Shiny application and complete the following tasks:

  1. Verify package support: Identify and ensure all required R packages for the module are available on CRAN.
  2. Code testing: Prepare and test the relevant R code to confirm it runs correctly and produces the expected output.
  3. Define inputs and outputs: Specify the parameters (inputs) and outputs that will be featured in the Shiny application.
  4. Select UI components: Choose suitable Shiny UI elements to present and control the identified parameters within the app interface.

2. Getting started

Our project utilizes open-source data from the World Happiness Report – Data Sharing covering the years 2015 to 2025. Detailed information on the data cleaning and preparation process is available on our group’s Netlify site, under the “Data Preparation” tab: Group Visual Analytic Application.

Specifically, our project focuses on the visualisation of global happiness trends. I will be exploring the modules related to geospatial and aspatial analysis to explore both spatial patterns and non-spatial factors influencing happiness scores across countries. For the prototyping of these modules, I will be using the cleaned_data dataset prepared after the data processing phase.

2.1 Loading R packages

The below R packages will be used in this exercise and for the Shiny application

Code
pacman::p_load(sf, tidyverse, tmap, dplyr,
               spatstat, spdep,
               lubridate, leaflet,
               plotly, DT, viridis,
               ggplot2, sfdep, janitor, shiny, rnaturalearth, shinyWidgets)

2.2 Reading the dataset

Code
happiness <- read.csv("data/world_happiness.csv")

2.3 Understanding the data structure

  • glimpse(): provides a transposed overview of a dataset, showing variables and their types in a concise format.
  • head(): displays the first few rows of a dataset (default is 6 rows) to give a quick preview of the data.
  • summary(): generates a statistical summary of each variable, including measures like mean, median, and range for numeric data.
  • duplicated():returns a logical vector indicating which elements or rows in a vector or data frame are duplicates.
  • colSums(is.na()): counts the number of missing values (NA) in each column of the data frame.
  • str(): use str() to display the column names, data types, and a preview of the data.
Code
glimpse(happiness)
Rows: 1,656
Columns: 12
$ year                 <int> 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2…
$ country              <chr> "Afghanistan", "Afghanistan", "Afghanistan", "Afg…
$ ladder_score         <dbl> 3.5750, 3.3600, 3.7940, 3.6320, 3.2030, 2.5669, 2…
$ economy_score        <dbl> 0.31982, 0.38227, 0.40148, 0.33200, 0.35000, 0.30…
$ social_score         <dbl> 0.30285, 0.11037, 0.58154, 0.53700, 0.51700, 0.35…
$ lifeexpectancy_score <dbl> 0.30335, 0.17344, 0.18075, 0.25500, 0.36100, 0.26…
$ freedom_score        <dbl> 0.23414, 0.16430, 0.10618, 0.08500, 0.00000, 0.00…
$ generosity_score     <dbl> 0.36510, 0.31268, 0.31187, 0.19100, 0.15800, 0.13…
$ corrperception_score <dbl> 0.09719, 0.07112, 0.06116, 0.03600, 0.02500, 0.00…
$ residual_score       <dbl> 1.95210, 2.14558, 2.15080, 2.19600, 1.79200, 1.50…
$ rank                 <int> 153, 154, 141, 145, 154, 153, 149, 146, 137, 143,…
$ region               <chr> "Asia", "Asia", "Asia", "Asia", "Asia", "Asia", "…
Code
head(happiness)
  year     country ladder_score economy_score social_score lifeexpectancy_score
1 2014 Afghanistan       3.5750       0.31982      0.30285              0.30335
2 2015 Afghanistan       3.3600       0.38227      0.11037              0.17344
3 2016 Afghanistan       3.7940       0.40148      0.58154              0.18075
4 2017 Afghanistan       3.6320       0.33200      0.53700              0.25500
5 2018 Afghanistan       3.2030       0.35000      0.51700              0.36100
6 2019 Afghanistan       2.5669       0.30071      0.35643              0.26605
  freedom_score generosity_score corrperception_score residual_score rank
1       0.23414          0.36510              0.09719        1.95210  153
2       0.16430          0.31268              0.07112        2.14558  154
3       0.10618          0.31187              0.06116        2.15080  141
4       0.08500          0.19100              0.03600        2.19600  145
5       0.00000          0.15800              0.02500        1.79200  154
6       0.00000          0.13523              0.00123        1.50724  153
  region
1   Asia
2   Asia
3   Asia
4   Asia
5   Asia
6   Asia
Code
summary(happiness)
      year        country           ladder_score   economy_score   
 Min.   :2014   Length:1656        Min.   :1.364   Min.   :0.0000  
 1st Qu.:2016   Class :character   1st Qu.:4.606   1st Qu.:0.7607  
 Median :2019   Mode  :character   Median :5.497   Median :1.0983  
 Mean   :2019                      Mean   :5.460   Mean   :1.0763  
 3rd Qu.:2022                      3rd Qu.:6.303   3rd Qu.:1.3946  
 Max.   :2024                      Max.   :7.842   Max.   :2.2090  
  social_score    lifeexpectancy_score freedom_score    generosity_score
 Min.   :0.0000   Min.   :0.0000       Min.   :0.0000   Min.   :0.0000  
 1st Qu.:0.8642   1st Qu.:0.4028       1st Qu.:0.3794   1st Qu.:0.1069  
 Median :1.1122   Median :0.5963       Median :0.4972   Median :0.1700  
 Mean   :1.0782   Mean   :0.5756       Mean   :0.4914   Mean   :0.1846  
 3rd Qu.:1.3496   3rd Qu.:0.7599       3rd Qu.:0.6110   3rd Qu.:0.2421  
 Max.   :1.8400   Max.   :1.1410       Max.   :1.0180   Max.   :0.8381  
 corrperception_score residual_score        rank           region         
 Min.   :0.0000       Min.   :-0.110   Min.   :  1.00   Length:1656       
 1st Qu.:0.0590       1st Qu.: 1.553   1st Qu.: 38.00   Class :character  
 Median :0.1010       Median : 1.922   Median : 76.00   Mode  :character  
 Mean   :0.1356       Mean   : 1.919   Mean   : 75.97                     
 3rd Qu.:0.1726       3rd Qu.: 2.309   3rd Qu.:113.25                     
 Max.   :0.5870       Max.   : 3.838   Max.   :158.00                     
Code
happiness[duplicated(happiness),]
 [1] year                 country              ladder_score        
 [4] economy_score        social_score         lifeexpectancy_score
 [7] freedom_score        generosity_score     corrperception_score
[10] residual_score       rank                 region              
<0 rows> (or 0-length row.names)
Note
  • Ensure that there are no duplicated columns, if not will have to investigate further.
Code
colSums(is.na(happiness))
                year              country         ladder_score 
                   0                    0                    0 
       economy_score         social_score lifeexpectancy_score 
                   0                    0                    0 
       freedom_score     generosity_score corrperception_score 
                   0                    0                    0 
      residual_score                 rank               region 
                   0                    0                    0 
Note
  • Ensure that there are no NA values, if not will have to investigate further.
  • Possibility to use drop_na() function to drop rows where any specified column contains a missing value.
Code
str(happiness)
'data.frame':   1656 obs. of  12 variables:
 $ year                : int  2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 ...
 $ country             : chr  "Afghanistan" "Afghanistan" "Afghanistan" "Afghanistan" ...
 $ ladder_score        : num  3.58 3.36 3.79 3.63 3.2 ...
 $ economy_score       : num  0.32 0.382 0.401 0.332 0.35 ...
 $ social_score        : num  0.303 0.11 0.582 0.537 0.517 ...
 $ lifeexpectancy_score: num  0.303 0.173 0.181 0.255 0.361 ...
 $ freedom_score       : num  0.234 0.164 0.106 0.085 0 ...
 $ generosity_score    : num  0.365 0.313 0.312 0.191 0.158 ...
 $ corrperception_score: num  0.0972 0.0711 0.0612 0.036 0.025 ...
 $ residual_score      : num  1.95 2.15 2.15 2.2 1.79 ...
 $ rank                : int  153 154 141 145 154 153 149 146 137 143 ...
 $ region              : chr  "Asia" "Asia" "Asia" "Asia" ...
Note
  • Ensure that all variables are correctly classified by data type; recast variable types if needed.
  • Variables are correctly classified - where categorical variables are classified as character, while continuous variables are classified as double.

The happiness tibble contains 12 attributes, as shown above.

The following preprocessing checks were conducted as part of data preparation:

Preprocessing Checks
  • Verified that the correct data types were loaded in the happiness dataset using glimpse() and str()
  • Ensured there were no duplicate variable names using duplicated() in the dataset
  • Checked for missing values using colSums(is.na())
Data scale review
  • The column values are already normalized, ranging consistently between 1 and 10 across countries.
  • No further standardization/ transformation is required, as the scale is uniform and interpretable for spatial analysis.

3. Geospatial analysis (Choropleth Map)

3.1 To load world country boundaries

To support the geospatial visualisation of global happiness trends, the rnaturalearth package was used to import spatial boundary data of countries. Specifically, the ne_countries() function was called with a medium scale and returned as a simple features (sf) object, stored in the world variable. This spatial dataset provides the geographic outlines of all countries and is essential for merging with the World Happiness Report data to enable choropleth mapping and spatial analysis at the country level.

Code
world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

3.2 Join happiness data with geospatial features

To prepare the dataset for mapping, the World Happiness data was filtered to include only records from the year 2024. This filtered data (happiness_latest) was then joined with the spatial dataset (world) using the country name as the common key. The resulting dataset, world_happy, combines both geographic boundaries and happiness scores, enabling geospatial visualisation of happiness levels across countries.

Code
# Join by country name 
happiness_latest <- happiness %>% 
  filter(year == 2024)  

world_happy <- world %>%
  left_join(happiness_latest, by = c("name" = "country"))

3.3 Choropleth map of global happiness (basic version)

The code below plots a choropleth map to visualise the distribution of happiness scores across countries for the year 2024. Using the tmap package in plotting mode, the tm_shape() function defines the spatial data object (world_happy), which contains both geographic boundaries and the associated happiness data. The tm_polygons() function maps the ladder_score variable to a blue color gradient, with darker shades indicating higher happiness scores.

Finally, tm_layout() adds a descriptive title and positions the legend outside the map area to improve clarity and presentation.

Code
tmap_mode("plot")
tmap_style("white")

tm_shape(world_happy) +
  tm_polygons("ladder_score", palette = "Blues", title = "Happiness Score (2024)") +
  tm_layout(title = "World Happiness Map", legend.outside = TRUE)

3.4 Choropleth map with styling and classification (enhanced version)

The enhanced choropleth map uses the tmap package with improved styling and classification. It applies a quantile-based scheme with five classes and the "YlOrRd" palette to represent ladder_score, where darker shades indicate higher happiness. Additional cartographic elements such as a compass, scale bar, borders, and descriptive credits are included to improve readability and interpretability. The legend is placed on the right, and a classic map style is applied for a cleaner layout.

Code
tmap_style("classic")
tmap_mode("plot")

tm_shape(world_happy) +
  tm_polygons(
    col = "ladder_score",
    palette = "YlOrRd",
    style = "quantile",
    n = 5,
    title = "Happiness Score (2024)"
  ) +
  tm_layout(
    main.title = "World Happiness Distribution (2024)",
    main.title.position = "center",
    main.title.size = 1.2,
    legend.outside = TRUE,
    legend.position = c("right", "center"),
    frame = FALSE
  ) +
  tm_borders(alpha = 0.4, lwd = 0.3) +
  tm_compass(type = "8star", size = 2, position = c("left", "top")) +
  tm_scale_bar(position = c("left", "bottom")) +
  tm_credits("Source: World Happiness Report 2024", position = c("left", "bottom")) +
  tm_credits("Note: Darker shades indicate higher happiness; lighter shades indicate lower happiness.",
             position = c("left", "bottom"), just = "right", size = 0.6)

3.5 Summary of differences between Basic version and Enhanced Version

Feature Basic Version Enhanced Version
Classification method Default(“pretty”) Custom(“quantile”, 5 bins)
Layout Basic title, default layout Custom title, centered, larger size
Map Borders None Added with transparency and thin width
Compass/ Scale Bar Not included Included
Credits/ Source Not included Included
Frame Default Removed

4. Drawing small multiple (faceted) map

The code below creates a faceted choropleth map to visualise global happiness scores from 2014 to 2024. Using tmap, each facet represents a different year, allowing for easy comparison of happiness trends across countries. Geospatial data is joined using rnaturalearth.

Code
# Load required libraries
library(tidyverse); library(tmap); library(sf); library(rnaturalearth)

# Set style and plotting mode
tmap_style("white")
tmap_mode("plot")

# Step 1: Filter for all years from 2014 to 2024
years_to_plot <- 2014:2024

happiness_subset <- happiness %>%
  filter(year %in% years_to_plot)

# Step 2: Load and join with world spatial data
world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

world_happy_facet <- world %>%
  left_join(happiness_subset, by = c("name" = "country"))

# Step 3: Filter out rows with missing ladder_score
world_happy_facet_clean <- world_happy_facet %>%
  filter(!is.na(ladder_score))

# Step 4: Plot small multiple choropleth maps
tm_shape(world_happy_facet_clean) +
  tm_polygons(
    col = "ladder_score",
    palette = "YlGnBu",
    style = "quantile",
    title = "Happiness Score"
  ) +
  tm_facets(by = "year", ncol = 2) +
  tm_layout(
    main.title = "Global Happiness Scores (2014–2024)",
    main.title.position = "center",
    panel.label.size = 2.0,
    legend.outside = TRUE,
    legend.position = c("center", "top"),   
    legend.height = 1.5,
    legend.width = 1.0,
    legend.text.size = 1.5,
    legend.title.size = 1.5,
    asp = 0,
    between.margin = 0,  
    outer.margins = c(0, 0, 0, 0)  
  )

5. Animating global happiness over time

The code below produces an animated choropleth map using ggplot2, gganimate, and sf to illustrate the temporal evolution of global happiness scores from 2014 to 2024. Country-level geospatial data from rnaturalearth is merged with World Happiness Report data via a spatial join. transition_time() is used to animate by year, with countries shaded according to their ladder score.

Note

Note: Although the underlying world geometries are static, the animated map may appear to shift or jitter between frames. This occurs because some countries are missing data in certain years, leading to variations in the number of plotted geometries. As a result, gganimate recalculates the plot layout for each frame, even when coord_sf(xlim, ylim, expand = FALSE) is specified. The visual movement is not due to changes in the map itself, but rather to inconsistencies in data availability across years.

Code
library(tidyverse); library(sf); library(gganimate); library(rnaturalearth)

# Load base world map
world <- ne_countries(scale = "medium", returnclass = "sf") %>%
  select(name, geometry)

# Load and clean happiness data
happiness <- read_csv("data/world_happiness.csv") %>%
  mutate(year = as.numeric(year)) %>%
  filter(year %in% 2014:2024)

# Merge first — keep all geometries
world_happy <- world %>%
  left_join(happiness, by = c("name" = "country")) %>%
  mutate(year = as.numeric(year))  

# Plot
ggplot(world_happy) +
  geom_sf(aes(fill = ladder_score)) +
  scale_fill_distiller(palette = "YlGnBu", direction = 1, na.value = "lightgray") +
  labs(title = "Happiness Score: {frame_time}", fill = "Score") +
  transition_time(year) +
  ease_aes('linear') +
  coord_sf(
    xlim = c(-180, 180),
    ylim = c(-60, 90),
    expand = FALSE
  )

6. Exploring global happiness with an interactive map - Choropleth Map

This code creates an interactive world map using tmap, showing happiness scores by country for 2024. Each country is colored based on its ladder score, and tooltips display the country name when hovered. The map allows users to zoom and explore happiness data visually.

Code
happiness_latest <- happiness_subset %>%
  filter(year == 2024)

world_happy_latest <- world %>%
  left_join(happiness_latest, by = c("name" = "country"))

world_happy_latest_clean <- world_happy_latest %>%
  filter(!is.na(ladder_score))

tmap_mode("view")

tm_shape(world_happy_latest_clean) +
  tm_polygons(
    col = "ladder_score",
    palette = "YlGnBu",
    id = "name",  # this is what shows in the tooltip
    title = "Happiness Score (2024)"
  )

7. User interface design - Part 1 - Choropleth Map

In this module, I developed an interactive Shiny application to visualize the World Happiness Score by country and year. The key input is a dropdown menu (selectInput) that allows users to choose a specific year from 2014 to 2024. Based on the selected year, the output is a dynamic choropleth map (tmapOutput) showing each country’s happiness score using a color gradient.

Note
  • Input: A single selectInput() allows the user to choose the Year of interest from 2014 to 2024.

  • Output: A dynamic choropleth map is rendered using tmap, updating automatically based on the selected year.

  • UI Components Used: selectInput() for the dropdown menu, sidebarPanel() for input placement, and tmapOutput() in mainPanel() for rendering the interactive map.

Code
# Load libraries
library(shiny); library(tmap); library(tidyverse); library(sf); library(rnaturalearth)

# Prepare data
happiness <- read_csv("data/world_happiness.csv") %>%
  mutate(year = as.numeric(year))

years_to_plot <- 2014:2024
happiness_subset <- happiness %>%
  filter(year %in% years_to_plot)

world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

world_happy <- world %>%
  left_join(happiness_subset, by = c("name" = "country"))

# UI
ui <- fluidPage(
  titlePanel("🌍 World Happiness Score by Year"),
  
  sidebarLayout(
    sidebarPanel(
      selectInput("selected_year", "Select Year:",
                  choices = sort(unique(world_happy$year)), 
                  selected = 2024)
    ),
    
    mainPanel(
      tmapOutput("happiness_map")
    )
  )
)

# Server
server <- function(input, output, session) {
  
  filtered_data <- reactive({
    world_happy %>%
      filter(year == input$selected_year & !is.na(ladder_score))
  })
  
  output$happiness_map <- renderTmap({
    tmap_mode("view")
    tm_shape(filtered_data()) +
      tm_polygons(
        col = "ladder_score",
        palette = "YlGnBu",
        id = "name",
        popup.vars = c("Country" = "name", "Happiness" = "ladder_score"),
        title = paste("Happiness Score:", input$selected_year)
      )
  })
}

# Run the app
shinyApp(ui, server)

Shiny applications not supported in static R Markdown documents

8. Mapping global happiness with proportional circles - Proportional Symbol Map

This code below generates an interactive map using leaflet to visualize 2024 World Happiness Scores. Each country is displayed as a circle located at its geographic center, with the size representing its happiness score. I added tooltips which provide additional insights like economy, life expectancy, freedom, and region, and a legend which helps interpret the happiness scores visually.

Code
library(tidyverse); library(readr); library(leaflet)
library(sf); library(rnaturalearth); library(rnaturalearthdata)

# Load the world happiness data
happiness <- read_csv("data/world_happiness.csv")

# Filter for a specific year (e.g., 2024)
happiness_latest <- happiness %>%
  filter(year == 2024)

# Load country polygons and calculate centroids
world_sf <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf") %>%
  select(name, geometry)

# Join happiness data to spatial data
world_happy_sf <- world_sf %>%
  left_join(happiness_latest, by = c("name" = "country")) %>%
  filter(!is.na(ladder_score))

# Get centroids for each country
world_happy_points <- st_centroid(world_happy_sf)

# Extract coordinates for leaflet
world_happy_points_coords <- world_happy_points %>%
  mutate(
    lon = st_coordinates(geometry)[, 1],
    lat = st_coordinates(geometry)[, 2]
  )

leaflet(world_happy_points_coords) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addCircleMarkers(
    lng = ~lon,
    lat = ~lat,
    radius = ~ladder_score * 3,
    color = "black",
    fillColor = ~colorNumeric("YlGnBu", domain = world_happy_points_coords$ladder_score)(ladder_score),
    fillOpacity = 0.6,  
    stroke = TRUE,
    weight = 0.5,
    popup = ~paste0(
      "<b>Country:</b> ", name, "<br/>",
      "<b>Happiness Score:</b> ", round(ladder_score, 2), "<br/>",
      "<b>Economy:</b> ", round(economy_score, 2), "<br/>",
      "<b>Life Expectancy:</b> ", round(lifeexpectancy_score, 2), "<br/>",
      "<b>Freedom:</b> ", round(freedom_score, 2), "<br/>",
      "<b>Region:</b> ", region
    )
  ) %>%
  addLegend(
    "bottomright",
    pal = colorNumeric("YlGnBu", domain = world_happy_points_coords$ladder_score),
    values = ~ladder_score,
    title = "Happiness Score",
    opacity = 1
  )

9. User interface design - Part 2 - Proportional Symbol Map

In this module, I developed an interactive Shiny application that allows users to explore the World Happiness Score by country for the year 2024. The main input is a country selector using selectInput, which enables users to search and select a country from the dropdown list. Once selected, the output is an interactive leaflet map that automatically zooms to the selected country and displays a popup with additional details such as economy, life expectancy, freedom, and region.

Note
  • Input: A selectInput() enables users to search and select a country from the list of available options for 2024.

  • Output: A proportional symbol map rendered via leaflet, displaying country-level happiness scores using interactive circle markers with popups.

  • UI Components Used: selectInput() in the sidebarPanel() for country selection, and leafletOutput() in the mainPanel() for displaying the dynamic map.

Code
library(shiny); library(tidyverse); library(readr); library(leaflet)
library(sf); library(rnaturalearth); library(rnaturalearthdata)

# Load and prepare data
happiness <- read_csv("data/world_happiness.csv") %>%
  filter(year == 2024)

world_sf <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf") %>%
  select(name, geometry)

world_happy_sf <- world_sf %>%
  left_join(happiness, by = c("name" = "country")) %>%
  filter(!is.na(ladder_score))

world_happy_points <- st_centroid(world_happy_sf)

world_happy_points_coords <- world_happy_points %>%
  mutate(
    lon = st_coordinates(geometry)[, 1],
    lat = st_coordinates(geometry)[, 2]
  )

# UI
ui <- fluidPage(
  titlePanel("World Happiness (2024)"),
  sidebarLayout(
    sidebarPanel(
      selectInput("selected_country", "Search Country:", 
                  choices = sort(world_happy_points_coords$name),
                  selected = NULL)
    ),
    mainPanel(
      leafletOutput("map", height = "400px")
    )
  )
)

# Server
server <- function(input, output, session) {
  
  output$map <- renderLeaflet({
    leaflet(world_happy_points_coords) %>%
      addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
      addCircleMarkers(
        lng = ~lon,
        lat = ~lat,
        radius = ~ladder_score * 3,
        color = "black",
        fillColor = ~colorNumeric("YlGnBu", domain = world_happy_points_coords$ladder_score)(ladder_score),
        fillOpacity = 0.6,
        stroke = TRUE,
        weight = 0.5,
        popup = ~paste0(
          "<b>Country:</b> ", name, "<br/>",
          "<b>Happiness Score:</b> ", round(ladder_score, 2), "<br/>",
          "<b>Economy:</b> ", round(economy_score, 2), "<br/>",
          "<b>Life Expectancy:</b> ", round(lifeexpectancy_score, 2), "<br/>",
          "<b>Freedom:</b> ", round(freedom_score, 2), "<br/>",
          "<b>Region:</b> ", region
        )
      ) %>%
      addLegend(
        "bottomright",
        pal = colorNumeric("YlGnBu", domain = world_happy_points_coords$ladder_score),
        values = ~ladder_score,
        title = "Happiness Score",
        opacity = 1
      )
  })
  
  observeEvent(input$selected_country, {
    selected_data <- world_happy_points_coords %>%
      filter(name == input$selected_country)
    
    leafletProxy("map") %>%
      setView(lng = selected_data$lon, lat = selected_data$lat, zoom = 5) %>%
      clearPopups() %>%
      addPopups(
        lng = selected_data$lon,
        lat = selected_data$lat,
        popup = paste0(
          "<b>Country:</b> ", selected_data$name, "<br/>",
          "<b>Happiness Score:</b> ", round(selected_data$ladder_score, 2), "<br/>",
          "<b>Economy:</b> ", round(selected_data$economy_score, 2), "<br/>",
          "<b>Life Expectancy:</b> ", round(selected_data$lifeexpectancy_score, 2), "<br/>",
          "<b>Freedom:</b> ", round(selected_data$freedom_score, 2), "<br/>",
          "<b>Region:</b> ", selected_data$region
        )
      )
  })
}

# Run the app
shinyApp(ui, server)

Shiny applications not supported in static R Markdown documents

10. User interface design - Part 3 - Combining both Choropleth Map & Proportional Symbol Map

This module presents an interactive Shiny application with two coordinated maps to explore World Happiness data. The Choropleth Map allows users to select a year and view global happiness patterns using a color gradient. In contrast, the Proportional Symbol Map lets users search for a country, zoom in automatically, and view detailed indicators such as economy, life expectancy, and freedom. Circle size and color reflect the happiness score.

After considering various approaches, I chose to implement both map types as they serve different purposes:

  • the choropleth reveals macro-level trends, while

  • the proportional map provides micro-level insights.

Tip
  • Note: This application includes smart interactivity features. Selecting a country from the dropdown automatically zooms both maps to the selected country for focused viewing. Conversely, clicking a country on the choropleth map will update the dropdown menu —ensuring two-way interaction. These features improve usability by making geographic exploration more intuitive and dynamic.
Note
  • Input: Two selectInput() components allow users to:

    • (1) choose the year (2014–2024), and

    • (2) search and select a country for focused exploration.

  • Output: A coordinated view using two maps:

    • A choropleth map rendered via tmap, showing happiness levels by country with zoom-to-country feature.

    • A proportional symbol map rendered with leaflet, displaying happiness scores with interactive popups.

  • UI Components Used: selectInput() (for year and country), sidebarPanel() for inputs, and tmapOutput() + leafletOutput() within a fluidRow() layout for side-by-side map rendering.

Code
# Load libraries
library(shiny); library(tidyverse); library(sf); library(tmap); library(leaflet); library(rnaturalearth)

# Load and prepare data
happiness <- read_csv("data/world_happiness.csv") %>%
  mutate(year = as.numeric(year)) %>%
  filter(year %in% 2014:2024)

world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

world_happy <- world %>%
  left_join(happiness, by = c("name" = "country"))

ui <- fluidPage(
  titlePanel("🌍 World Happiness Explorer"),
  sidebarLayout(
    sidebarPanel(
      selectInput("selected_year", "Select Year:", choices = sort(unique(world_happy$year)), selected = 2024),
      selectInput("selected_country", "Search Country:", choices = NULL)
    ),
    mainPanel(
      fluidRow(
        column(6,
               h4("Choropleth Map"),
               tmapOutput("choropleth_map", height = "500px")
        ),
        column(6,
               h4("Proportional Symbol Map"),
               leafletOutput("prop_map", height = "500px")
        )
      )
    )
  )
)

server <- function(input, output, session) {

  # Reactive filtered data by year
  filtered_data <- reactive({
    world_happy %>%
      filter(year == input$selected_year & !is.na(ladder_score))
  })

  # Update dropdown
  observe({
    updateSelectInput(session, "selected_country",
                      choices = sort(unique(filtered_data()$name)))
  })

  # Choropleth Map - Zoom to selected country
  output$choropleth_map <- renderTmap({
    tmap_mode("view")

    # Zooming to selected country
    selected_geom <- filtered_data() %>% filter(name == input$selected_country)
    bbox_zoom <- if (nrow(selected_geom) > 0) st_bbox(selected_geom) else st_bbox(filtered_data())

    tm_shape(filtered_data(), bbox = bbox_zoom) +
      tm_polygons(
        col = "ladder_score",
        palette = "YlGnBu",
        id = "name",
        popup.vars = c("Country" = "name", "Happiness" = "ladder_score"),
        title = paste("Happiness Score:", input$selected_year)
      )
  })

  # Proportional Symbol Map
  output$prop_map <- renderLeaflet({
    centroids <- st_centroid(filtered_data())
    coords <- centroids %>%
      mutate(
        lon = st_coordinates(geometry)[, 1],
        lat = st_coordinates(geometry)[, 2]
      )

    leaflet(coords) %>%
      addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
      addCircleMarkers(
        lng = ~lon,
        lat = ~lat,
        radius = ~ladder_score * 3,
        color = "black",
        fillColor = ~colorNumeric("YlGnBu", domain = coords$ladder_score)(ladder_score),
        fillOpacity = 0.6,
        stroke = TRUE,
        weight = 0.5,
        popup = ~paste0(
          "<b>Country:</b> ", name, "<br/>",
          "<b>Happiness Score:</b> ", round(ladder_score, 2), "<br/>",
          "<b>Economy:</b> ", round(economy_score, 2), "<br/>",
          "<b>Life Expectancy:</b> ", round(lifeexpectancy_score, 2), "<br/>",
          "<b>Freedom:</b> ", round(freedom_score, 2), "<br/>",
          "<b>Region:</b> ", region
        )
      ) %>%
      addLegend(
        "bottomright",
        pal = colorNumeric("YlGnBu", domain = coords$ladder_score),
        values = ~ladder_score,
        title = "Happiness Score",
        opacity = 1
      )
  })

  # Auto-zoom on dropdown change
  observeEvent(input$selected_country, {
    centroids <- st_centroid(filtered_data())
    coords <- centroids %>%
      mutate(
        lon = st_coordinates(geometry)[, 1],
        lat = st_coordinates(geometry)[, 2]
      )

    selected_data <- coords %>% filter(name == input$selected_country)

    leafletProxy("prop_map") %>%
      setView(lng = selected_data$lon, lat = selected_data$lat, zoom = 5) %>%
      clearPopups() %>%
      addPopups(
        lng = selected_data$lon,
        lat = selected_data$lat,
        popup = paste0(
          "<b>Country:</b> ", selected_data$name, "<br/>",
          "<b>Happiness Score:</b> ", round(selected_data$ladder_score, 2), "<br/>",
          "<b>Economy:</b> ", round(selected_data$economy_score, 2), "<br/>",
          "<b>Life Expectancy:</b> ", round(selected_data$lifeexpectancy_score, 2), "<br/>",
          "<b>Freedom:</b> ", round(selected_data$freedom_score, 2), "<br/>",
          "<b>Region:</b> ", selected_data$region
        )
      )
  })

  # Auto-update dropdown when user clicks map
  observeEvent(input$choropleth_map_shape_click, {
    clicked_country <- input$choropleth_map_shape_click$id
    updateSelectInput(session, "selected_country", selected = clicked_country)
  })
}

shinyApp(ui, server)

Shiny applications not supported in static R Markdown documents

11. User interface design - Part 4 - Adding regions

In this iteration, I introduced an additional selectInput() to filter the data by region. This allows users to explore happiness scores by broader geographical areas before narrowing down to a specific country. The country dropdown is dynamically updated based on the selected region, and selecting a country will also auto-update the region field—ensuring a smooth and linked filtering experience.

Tip
  • Note: To explore and ensure that the region and country drop downs are dynamically linked, where
    • selecting a region will show only countries in that region
    • selecting a country will auto-update the region selection to match
Note
  • Input: Three selectInput() components allow users to:

    • (1) choose the year (2014–2024),

    • (2) filter by region,

    • (3) search and select a country within a selected region.

  • Output: A coordinated view using two maps:

    • A choropleth map rendered via tmap, showing happiness levels by country with zoom-to-country feature.

    • A proportional symbol map rendered with leaflet, displaying happiness scores with interactive popups.

  • UI Components Used: selectInput() (for year, region and country), sidebarPanel() for inputs, and tmapOutput() + leafletOutput() within a fluidRow() layout for side-by-side map rendering.

Code
# Load libraries
library(shiny); library(tidyverse); library(sf); library(tmap); library(leaflet); library(rnaturalearth); library(readr)

# Load and prepare data
happiness <- read_csv("data/world_happiness.csv") %>%
  mutate(year = as.numeric(year)) %>%
  filter(year %in% 2014:2024)

world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")
world_happy <- world %>%
  left_join(happiness, by = c("name" = "country"))

# UI
ui <- fluidPage(
  titlePanel("🌍 World Happiness Explorer"),
  sidebarLayout(
    sidebarPanel(
      selectInput("selected_year", "Select Year:",
                  choices = sort(unique(world_happy$year)),
                  selected = 2024),
      selectInput("selected_region", "Filter by Region:", choices = NULL),
      selectInput("selected_country", "Search Country:", choices = NULL)
    ),
    mainPanel(
      fluidRow(
        column(6,
               h4("Choropleth Map"),
               tmapOutput("choropleth_map", height = "500px")
        ),
        column(6,
               h4("Proportional Symbol Map"),
               leafletOutput("prop_map", height = "500px")
        )
      )
    )
  )
)

# Server
server <- function(input, output, session) {

  # Initial values for dropdowns
  observe({
    regions <- sort(unique(world_happy$region))
    updateSelectInput(session, "selected_region",
                      choices = c("All", regions),
                      selected = "All")
  })

  # Reactive filtered data
  filtered_data <- reactive({
    data <- world_happy %>%
      filter(year == input$selected_year & !is.na(ladder_score))
    if (input$selected_region != "All") {
      data <- data %>% filter(region == input$selected_region)
    }
    return(data)
  })

  # Update country dropdown when region changes
  observeEvent(input$selected_region, {
    countries <- filtered_data() %>% pull(name) %>% unique() %>% sort()
    updateSelectInput(session, "selected_country",
                      choices = countries,
                      selected = countries[1])
  })

  # Update region when country changes
  observeEvent(input$selected_country, {
    selected_region <- world_happy %>%
      filter(name == input$selected_country,
             year == input$selected_year) %>%
      pull(region) %>% unique()

    if (!is.null(selected_region) && length(selected_region) == 1) {
      updateSelectInput(session, "selected_region",
                        selected = selected_region)
    }
  })

  # Choropleth Map
  output$choropleth_map <- renderTmap({
    tmap_mode("view")
    selected_geom <- filtered_data() %>% filter(name == input$selected_country)
    bbox_zoom <- if (nrow(selected_geom) > 0) st_bbox(selected_geom) else st_bbox(filtered_data())

    tm_shape(filtered_data(), bbox = bbox_zoom) +
      tm_polygons(
        col = "ladder_score",
        palette = "YlGnBu",
        id = "name",
        popup.vars = c("Country" = "name", "Happiness" = "ladder_score"),
        title = paste("Happiness Score:", input$selected_year)
      )
  })

  # Proportional Symbol Map
  output$prop_map <- renderLeaflet({
    centroids <- st_centroid(filtered_data())
    coords <- centroids %>%
      mutate(
        lon = st_coordinates(geometry)[, 1],
        lat = st_coordinates(geometry)[, 2]
      )

    leaflet(coords) %>%
      addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
      addCircleMarkers(
        lng = ~lon,
        lat = ~lat,
        radius = ~ladder_score * 3,
        color = "black",
        fillColor = ~colorNumeric("YlGnBu", domain = coords$ladder_score)(ladder_score),
        fillOpacity = 0.6,
        stroke = TRUE,
        weight = 0.5,
        popup = ~paste0(
          "<b>Country:</b> ", name, "<br/>",
          "<b>Happiness Score:</b> ", round(ladder_score, 2), "<br/>",
          "<b>Economy:</b> ", round(economy_score, 2), "<br/>",
          "<b>Life Expectancy:</b> ", round(lifeexpectancy_score, 2), "<br/>",
          "<b>Freedom:</b> ", round(freedom_score, 2), "<br/>",
          "<b>Region:</b> ", region
        )
      ) %>%
      addLegend(
        "bottomright",
        pal = colorNumeric("YlGnBu", domain = coords$ladder_score),
        values = ~ladder_score,
        title = "Happiness Score",
        opacity = 1
      )
  })

  # Auto-zoom and popup on country selection
  observeEvent(input$selected_country, {
    centroids <- st_centroid(filtered_data())
    coords <- centroids %>%
      mutate(
        lon = st_coordinates(geometry)[, 1],
        lat = st_coordinates(geometry)[, 2]
      )

    selected_data <- coords %>% filter(name == input$selected_country)

    if (nrow(selected_data) > 0 &&
        !is.na(selected_data$lon) &&
        !is.na(selected_data$lat)) {
      leafletProxy("prop_map") %>%
        setView(lng = selected_data$lon, lat = selected_data$lat, zoom = 5) %>%
        clearPopups() %>%
        addPopups(
          lng = selected_data$lon,
          lat = selected_data$lat,
          popup = paste0(
            "<b>Country:</b> ", selected_data$name, "<br/>",
            "<b>Happiness Score:</b> ", round(selected_data$ladder_score, 2), "<br/>",
            "<b>Economy:</b> ", round(selected_data$economy_score, 2), "<br/>",
            "<b>Life Expectancy:</b> ", round(selected_data$lifeexpectancy_score, 2), "<br/>",
            "<b>Freedom:</b> ", round(selected_data$freedom_score, 2), "<br/>",
            "<b>Region:</b> ", selected_data$region
          )
        )
    }
  })
}

# Run the app
shinyApp(ui, server)

Shiny applications not supported in static R Markdown documents

12. Exploring the LISA cluster map

In the next tab of our Cluster and Outlier Analysis module, I will apply spatial autocorrelation techniques to the World Happiness dataset by generating a Moran Scatter Plot and a Local Indicators of Spatial Association (LISA) Cluster Map.

The Moran Scatter Plot will quantify global spatial autocorrelation, indicating the overall degree of spatial clustering of happiness scores. The LISA Cluster Map will identify statistically significant local clusters—such as High-High (hot spots), Low-Low (cold spots), and spatial outliers (High-Low, Low-High) —allowing for granular analysis of localized spatial dependencies.

12.1 Plotting the Moran Scatter Plot

The Moran scatter plot visualizes the spatial autocorrelation between countries’ Happiness Scores and the average scores of their neighboring countries. This helps us understand whether similar levels of happiness cluster geographically.

In the example below, we use moran.plot() from the spdep package to explore spatial relationships in the 2024 Happiness Score:

Interpretation:

The plot is split into four quadrants, each representing a different spatial relationship:

  • High-High (HH) - Top right quadrant: countries with high happiness surrounded by similarly happy neighbors.

  • Low-Low (LL) - Bottom-left quadrant: countries with low happiness surrounded by similarly unhappy neighbors.

  • High-Low (HL) - Bottom-right quadrant: — countries with high happiness surrounded by low-scoring neighbors (potential outliers).

  • Low-High (LH) - Top-left: countries with low happiness surrounded by happy neighbors (also outliers).

Tip

Note: Scatter plot provides an intuitive way to detect potential spatial clusters/ anomalies.

Code
# Filter and prepare data
world_2024 <- world_happy %>% filter(year == 2024 & !is.na(ladder_score))

# Create spatial weights (Queen's case)
coords <- st_coordinates(st_centroid(world_2024))
nb <- poly2nb(world_2024, queen = TRUE)
lw <- nb2listw(nb, style = "W", zero.policy = TRUE)

# Moran Scatter Plot
moran.plot(world_2024$ladder_score, lw,
           labels = as.character(world_2024$name),
           xlab = "Happiness Score (2024)",
           ylab = "Spatially Lagged Happiness Score",
           zero.policy = TRUE)

12.2 Preparing LISA cluster class for Word Happiness (2024)

Before generating the LISA cluster map, it is important to center both the spatially lagged variable and the local Moran’s I values. This step allows us to identify meaningful spatial patterns by determining whether a country’s happiness score — and the scores of its neighbors — are above or below the global mean.

By doing so, we can categorize each country into one of the four LISA cluster types (High-High, Low-Low, High-Low, Low-High). These classifications are only meaningful when assessed relative to the dataset’s overall distribution, making centering a crucial step for accurate spatial interpretation.

Code
# Load required library
library(spdep)

# Step 1: Filter 2024 data
world_2024 <- world_happy %>% 
  filter(year == 2024 & !is.na(ladder_score))

# Step 2: Create spatial weights
coords <- st_coordinates(st_centroid(world_2024))
nb <- poly2nb(world_2024, queen = TRUE)
lw <- nb2listw(nb, style = "W", zero.policy = TRUE)

# Step 3: Calculate local Moran's I
localMI <- localmoran(world_2024$ladder_score, lw, zero.policy = TRUE)

# Step 4: Spatial lag of the variable
world_2024$lag_score <- lag.listw(lw, world_2024$ladder_score)

# Step 5: Center spatial lag & local Moran's I
DV <- world_2024$lag_score - mean(world_2024$lag_score, na.rm = TRUE)
LM_I <- localMI[, 1] - mean(localMI[, 1], na.rm = TRUE)

# Step 6: Set significance threshold
signif <- 0.05

# Step 7: Create quadrant classification
quadrant <- vector(mode = "numeric", length = nrow(localMI))
quadrant[DV < 0 & LM_I > 0] <- 1   # Low-Low
quadrant[DV > 0 & LM_I < 0] <- 2   # High-Low
quadrant[DV < 0 & LM_I < 0] <- 3   # Low-High
quadrant[DV > 0 & LM_I > 0] <- 4   # High-High
quadrant[localMI[, 5] > signif] <- 0  # Not significant

# Step 8: Assign quadrant to data
world_2024$quadrant <- quadrant

12.3 Plotting the LISA Cluster Map

The LISA Cluster Map provides a powerful visualization of localized spatial autocorrelation in happiness scores for 2024. By computing the local Moran’s I for each country, we can identify areas where similar values cluster together (e.g., high-scoring countries surrounded by other high scorers), or where outliers emerge (e.g., a low-scoring country surrounded by high scorers).

Each country is classified into one of five categories based on the direction and significance of the spatial relationship:

Category Description Statistically significant? Interpretation
HH High with high neighbours Yes Cluster (hot spot)
LL Low with low neighbours Yes Cluster (cold spot)
HL High with low neighbours Yes Spatial outlier
LH Low with high neighbours Yes Spatial outlier
Insignificant No strong autocorrelation No Insignifcant
Code
# Load libraries
library(tidyverse); library(sf); library(spdep); library(tmap); library(readr); library(rnaturalearth)

# Load and prepare data
happiness <- read_csv("data/world_happiness.csv") %>%
  mutate(year = as.numeric(year)) %>%
  filter(year == 2024 & !is.na(ladder_score))

world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

# Join spatial and happiness data
world_2024 <- world %>%
  left_join(happiness, by = c("name" = "country")) %>%
  filter(!is.na(ladder_score))

# Construct spatial weights
nb <- poly2nb(world_2024, queen = TRUE)
lw <- nb2listw(nb, style = "W", zero.policy = TRUE)

# Compute local Moran's I
localMI <- localmoran(world_2024$ladder_score, lw, zero.policy = TRUE)

# Prepare LISA categories
quadrant <- vector(mode = "numeric", length = nrow(localMI))
lagged_score <- lag.listw(lw, world_2024$ladder_score)
centered_lag <- lagged_score - mean(lagged_score)
centered_localMI <- localMI[, 1] - mean(localMI[, 1])
significance_level <- 0.05

# Define cluster categories
quadrant[centered_lag < 0 & centered_localMI > 0] <- 1  # Low-Low
quadrant[centered_lag > 0 & centered_localMI < 0] <- 2  # Low-High
quadrant[centered_lag < 0 & centered_localMI < 0] <- 3  # High-Low
quadrant[centered_lag > 0 & centered_localMI > 0] <- 4  # High-High
quadrant[localMI[, 5] > significance_level] <- 0        # Not significant

# Add to spatial data
world_2024$quadrant <- quadrant
world_2024$cluster_label <- factor(
  quadrant,
  levels = 0:4,
  labels = c("Insignificant", "Low-Low", "Low-High", "High-Low", "High-High")
)

# Plot the interactive LISA cluster map using tmap
tmap_mode("view")

tm_shape(world_2024) +
  tm_fill(
    col = "cluster_label",
    palette = c(
      "Insignificant" = "#ffffcc",
      "Low-Low" = "blue",
      "Low-High" = "#78c679",
      "High-Low" = "#c2e699",
      "High-High" = "red"
    ),
    title = "LISA Cluster (2024)",
    style = "cat",
    id = "admin",  
    popup.vars = c(
      "Country" = "admin",
      "Cluster Type" = "cluster_label",
      "Happiness Score" = "ladder_score"
    )
  ) +
  tm_borders(alpha = 0.4) +
  tm_layout(
    frame = FALSE,
    legend.outside = TRUE
  )

12.4 User interface design - Combining Moran Scattterplot with LISA map

This module introduces a new spatial analysis feature that combines a Moran Scatterplot with a LISA Cluster Map. The Moran Scatterplot provides a global view of spatial autocorrelation, illustrating how each country’s happiness score relates to the average score of its neighbors. In parallel, the LISA map highlights local spatial clusters—such as High-High, Low-Low, and spatial outliers — offering a geographically contextualized view of well-being.

Note
  • Input: A selectedInput() let users choose the year (2014-2024) to filter the happiness dataset.

  • Output: A coordinated view using two spatial visualizations:

    • A Moran Scatterplot displaying global spatial autocorrelation by plotting each country’s happiness score against the average of its neighbors, highlighting overall clustering trends.

    • A LISA Cluster Map rendered via tmap, showing local spatial clusters (e.g., HH, LL) and outliers (e.g., HL, LH)

  • UI Components Used: selectInput() for year input, sidebarPanel() for layout, plotOutput() and tmapOutput() for visualizations, fluidRow() and column() for alignment,

Code
# Load libraries
library(shiny); library(tidyverse); library(sf); library(spdep); library(tmap); library(readr); library(rnaturalearth)

# Load and prepare data
happiness <- read_csv("data/world_happiness.csv") %>%
  mutate(year = as.numeric(year)) %>%
  filter(!is.na(ladder_score))

world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

# UI
ui <- navbarPage("World Happiness Explorer",

  tabPanel("Spatial Clustering",
    fluidPage(
      titlePanel("🌐 Spatial Clustering Analysis"),

      sidebarLayout(
        sidebarPanel(
  selectInput("selected_year", "Select Year:",
              choices = sort(unique(happiness$year)),
              selected = 2024),
  hr(),
  h4("Chart Interpretation"),
  HTML(
    "The Moran scatterplot shows how each country's happiness score correlates with its neighbors'.<br><br>
    The LISA Cluster map highlights statistically significant spatial clusters:<br>
    - <b style='color:red;'>High-High</b>: Top right quadrant - Happy countries near other happy countries<br>
    - <b style='color:blue;'>Low-Low</b>: Bottom left quadrant - Unhappy countries near unhappy neighbors<br>
    - <b style='color:#78c679;'>Low-High</b>: Top left quadrant - Potential outliers<br>
    - <b style='color:#c2e699;'>High-Low</b>: Bottom right quandrant - Potential outliers<br>
    - <b style='color:#ffffcc;'>Insignificant</b>: No strong spatial pattern"
  )
),

        mainPanel(
          fluidRow(
            column(6, plotOutput("moran_plot", height = "500px")),
            column(6, tmapOutput("lisa_map", height = "500px"))
          )
        )
      )
    )
  )
)

# Server
server <- function(input, output, session) {

  world_data <- reactive({
    data <- happiness %>%
      filter(year == input$selected_year)

    world %>%
      left_join(data, by = c("name" = "country")) %>%
      filter(!is.na(ladder_score))
  })

  # Spatial weights and local moran
  local_moran <- reactive({
    data <- world_data()
    nb <- poly2nb(data, queen = TRUE)
    lw <- nb2listw(nb, style = "W", zero.policy = TRUE)
    localMI <- localmoran(data$ladder_score, lw, zero.policy = TRUE)

    lagged_score <- lag.listw(lw, data$ladder_score)
    centered_lag <- lagged_score - mean(lagged_score)
    centered_localMI <- localMI[, 1] - mean(localMI[, 1])

    quadrant <- vector(mode = "numeric", length = nrow(data))
    quadrant[centered_lag < 0 & centered_localMI > 0] <- 1  # Low-Low
    quadrant[centered_lag > 0 & centered_localMI < 0] <- 2  # Low-High
    quadrant[centered_lag < 0 & centered_localMI < 0] <- 3  # High-Low
    quadrant[centered_lag > 0 & centered_localMI > 0] <- 4  # High-High
    quadrant[localMI[, 5] > 0.05] <- 0                      # Not significant

    data$quadrant <- quadrant
    data$cluster_label <- factor(
      quadrant,
      levels = 0:4,
      labels = c("Insignificant", "Low-Low", "Low-High", "High-Low", "High-High")
    )

    list(data = data, lw = lw)
  })

  # Moran plot
  output$moran_plot <- renderPlot({
    dat <- local_moran()
    moran.plot(dat$data$ladder_score, dat$lw,
               labels = dat$data$name,
               xlab = paste("Happiness Score (", input$selected_year, ")", sep = ""),
               ylab = "Spatially Lagged Happiness Score",
               zero.policy = TRUE)
  })

  # LISA map
  output$lisa_map <- renderTmap({
    tmap_mode("view")

    tm_shape(local_moran()$data) +
      tm_fill(
        col = "cluster_label",
        palette = c(
          "Insignificant" = "#ffffcc",
          "Low-Low" = "blue",
          "Low-High" = "#78c679",
          "High-Low" = "#c2e699",
          "High-High" = "red"
        ),
        title = paste("LISA Cluster (", input$selected_year, ")", sep = ""),
        style = "cat",
        id = "admin",
        popup.vars = c(
          "Country" = "admin",
          "Cluster Type" = "cluster_label",
          "Happiness Score" = "ladder_score"
        )
      ) +
      tm_borders(alpha = 0.4) +
      tm_layout(frame = FALSE, legend.outside = TRUE)
  })
}

shinyApp(ui = ui, server = server)

Shiny applications not supported in static R Markdown documents

13. Aspatial analysis

13.1 Aspatial analysis - Region ridgeplot

This module presents an aspatial analysis using a ridge plot to explore the distribution of happiness scores across different world regions. By removing the spatial dimension, this visualization emphasizes the overall spread and concentration of scores within each region. The density ridges highlight patterns such as regional skewness, central tendencies, and outliers—enabling direct comparison between continents like Europe, Asia, and Africa.

Code
library(ggplot2); library(ggridges)

ggplot(happiness, aes(x = ladder_score, y = region, fill = region)) +
  geom_density_ridges(alpha = 0.7, scale = 1.2) +
  theme_minimal() +
  labs(title = "Distribution of Happiness Scores by Region (2024)",
       x = "Happiness Score (Ladder Score)",
       y = "Region")

13.2 Aspatial analysis - Countries level score summary table

This section presents an aspatial summary of happiness scores at the country level from 2014 to 2024. By computing the minimum, maximum, and average ladder scores for each country across the 11-year period, this table provides a historical perspective of well-being. The most recent score from 2024 is displayed alongside a bullet chart, which visually compares the 2024 score to the country’s average—highlighting whether a country’s current happiness level is above or below its long-term trend.

Note

Summary table column names representation:

  • Min Score (2014–24): The lowest happiness score recorded for the country during the period.
  • Max Score (2014–24): The highest recorded score in the same period.
  • Avg Score (2014–24): The mean score across all available years.
  • 2024 Score The most recent score for the selected year.
  • 2024 vs Avg: A bullet chart that shows how the 2024 score compares with the country’s historical average (black line = average, blue bar = actual score).
Code
library(tidyverse); library(gt); library(gtExtras)

# Step 1: Compute summary stats per country
summary_df <- happiness %>%
  group_by(country) %>%
  summarise(
    MIN = min(ladder_score, na.rm = TRUE),
    MAX = max(ladder_score, na.rm = TRUE),
    AVERAGE = mean(ladder_score, na.rm = TRUE)
  )

# Step 2: Get latest year ladder_score
latest_scores <- happiness %>%
  filter(year == max(year)) %>%
  select(country, ACTUAL = ladder_score)

# Step 3: Combine both
combined_df <- summary_df %>%
  left_join(latest_scores, by = "country") %>%
  arrange(country) %>%
  mutate(bullet_chart = ACTUAL) %>%
  select(country, MIN, MAX, AVERAGE, ACTUAL, bullet_chart)


# Step 4: Generate the final table
combined_df %>%
  gt() %>%
  cols_label(
    country = "COUNTRY",
    MIN = "Min Score\n(2014–24)",
    MAX = "Max Score\n(2014–24)",
    AVERAGE = "Avg Score\n(2014–24)",
    ACTUAL = "2024\nScore",
    bullet_chart = "2024 vs Avg"
  ) %>%
  gt_plt_bullet(column = bullet_chart, target = AVERAGE, palette = c("lightblue", "black")) %>%
  tab_header(title = "Happiness Score Dashboard: 2014–2024")
Happiness Score Dashboard: 2014–2024
COUNTRY Min Score (2014–24) Max Score (2014–24) 2024 Score 2024 vs Avg
Afghanistan 1.3640 3.7940 1.364
Albania 4.5860 5.4110 5.411
Algeria 4.8870 6.3550 5.571
Angola 3.7950 4.0330 NA
Argentina 5.9290 6.6500 6.397
Armenia 4.3210 5.4940 5.494
Australia 6.9740 7.3130 6.974
Austria 6.8100 7.2942 6.810
Azerbaijan 4.8750 5.2910 4.875
Bahrain 5.9590 6.6470 6.030
Bangladesh 3.8510 5.1550 3.851
Belarus 5.3230 5.8210 NA
Belgium 6.8050 6.9370 6.910
Belize 5.9560 6.7110 6.711
Benin 3.3400 5.2160 4.357
Bhutan 5.0110 5.2530 NA
Bolivia, Plurinational State of 5.6000 5.8900 5.868
Bosnia and Herzegovina 4.9490 6.1360 6.136
Botswana 3.3830 4.3320 3.438
Brazil 6.1250 6.9830 6.494
Bulgaria 4.2170 5.5540 5.554
Burkina Faso 3.5870 4.8340 4.383
Burundi 2.9050 3.7753 NA
Cambodia 3.8190 4.8484 4.341
Cameroon 4.2520 5.1420 4.887
Canada 6.8030 7.4270 6.803
Central African Republic 2.6930 3.6780 NA
Chad 3.6670 4.4710 4.384
Chile 6.1720 6.7050 6.361
China 5.1239 5.9730 5.921
Colombia 5.6300 6.4810 6.004
Comoros 3.5450 4.6090 3.754
Congo 3.9890 5.3420 NA
Congo, Democratic Republic of the 3.2070 5.0750 5.030
Costa Rica 6.5820 7.2740 7.274
Croatia 5.2930 6.1250 5.870
Cyprus 5.9420 6.1300 5.942
Czechia 6.5050 6.9650 6.775
Côte d'Ivoire 3.6550 5.3060 NA
Côte d’Ivoire 5.1020 5.1020 5.102
DR Congo 3.4690 3.4690 3.469
Denmark 7.5210 7.6456 7.521
Djibouti 4.3690 4.3690 NA
Dominican Republic 4.8850 5.8460 5.846
Ecuador 5.5330 6.0280 5.965
Egypt 3.8170 4.7350 3.817
El Salvador 6.0030 6.4920 6.492
Estonia 5.4290 6.4550 6.417
Eswatini 3.5020 4.8670 3.774
Ethiopia 3.8610 4.5120 3.898
Finland 7.4060 7.8420 7.736
France 6.4420 6.6900 6.593
Gabon 3.8960 5.1200 5.120
Gambia 4.2790 5.1640 4.423
Georgia 4.2520 5.4000 5.400
Germany 6.7190 7.1550 6.753
Ghana 4.1200 5.1480 4.340
Greece 4.8570 5.9480 5.776
Guatemala 6.1230 6.4540 6.362
Guinea 3.5070 5.0720 4.929
Haiti 3.5820 4.5180 NA
Honduras 4.7880 6.0230 5.964
Hong Kong 5.3080 5.5104 NA
Hong Kong SAR of China 5.4910 5.4910 5.491
Hungary 4.8000 6.0860 5.915
Iceland 7.4940 7.5610 7.515
India 3.5733 4.5650 4.389
Indonesia 5.0930 5.6170 5.617
Iran, Islamic Republic of 4.5480 5.0930 5.093
Iraq 4.4370 5.1660 4.976
Ireland 6.8380 7.0937 6.889
Israel 6.8140 7.4730 7.234
Italy 5.9480 6.4830 6.415
Jamaica 5.3110 6.3090 5.870
Japan 5.8708 6.1470 6.147
Jordan 4.1200 5.3360 4.310
Kazakhstan 5.7900 6.3780 6.378
Kenya 4.3560 4.6070 4.510
Korea, Democratic People's Republic of 5.8350 6.0580 NA
Kosovo 5.2790 6.6590 6.659
Kuwait 6.0210 6.9510 6.629
Kyrgyzstan 5.0040 5.8580 5.858
Lao PDR 5.3010 5.3010 5.301
Lao People's Democratic Republic 4.6230 5.1400 NA
Latvia 5.0980 6.2340 6.207
Lebanon 2.3920 5.3580 3.188
Lesotho 3.1860 4.8980 3.757
Liberia 3.4950 5.1220 4.277
Libya 5.3300 5.8660 5.820
Lithuania 5.8130 6.8290 6.829
Luxembourg 6.8630 7.4040 7.122
Madagascar 3.6440 4.3390 4.157
Malawi 3.2600 4.2920 3.260
Malaysia 5.3390 6.3220 5.955
Maldives 5.1976 5.1980 NA
Mali 3.9950 4.7293 4.345
Malta 6.3000 6.7728 6.316
Mauritania 4.1530 4.7240 4.542
Mauritius 5.4770 6.1013 5.832
Mexico 6.1280 7.1870 6.979
Moldova, Republic of 5.5290 5.8970 NA
Mongolia 4.8740 5.8400 5.833
Montenegro 5.1610 5.8770 5.877
Morocco 4.6220 5.2540 4.622
Mozambique 4.4170 5.2160 5.190
Myanmar 4.3070 4.5450 4.321
Namibia 4.4410 4.9110 4.911
Nepal 4.5140 5.3770 5.311
Netherlands, Kingdom of the 7.3060 7.4880 7.306
New Zealand 6.9520 7.3340 6.952
Nicaragua 5.8280 6.3300 6.330
Niger 3.8450 5.0740 4.725
Nigeria 4.5520 5.2680 4.885
North Cyprus 5.4670 6.0460 NA
North Macedonia 5.0070 5.5030 5.503
Northern Cyprus 5.5460 6.2230 NA
Norway 7.2620 7.5940 7.262
Oman 6.8530 6.8530 NA
Pakistan 4.5160 5.6933 4.768
Palestine, State of 4.4830 4.9080 4.780
Panama 6.1800 6.7860 6.407
Paraguay 5.4930 6.1720 6.172
Peru 5.5260 5.9470 5.947
Philippines 5.0730 6.1070 6.107
Poland 5.7910 6.6730 6.673
Portugal 5.1020 6.0300 6.013
Puerto Rico 7.0390 7.0390 NA
Qatar 6.3740 6.6110 NA
Republic of Korea 6.0380 6.0380 6.038
Republic of Moldova 5.8190 5.8190 5.819
Romania 5.1240 6.5890 6.563
Russian Federation 5.4590 5.9630 5.945
Rwanda 3.2680 3.5150 NA
Saudi Arabia 6.3440 6.6000 6.600
Senegal 3.9040 5.1320 4.856
Serbia 5.1230 6.6060 6.606
Sierra Leone 2.9980 4.7090 2.998
Singapore 6.2620 6.7980 6.565
Slovakia 5.9950 6.4690 6.221
Slovenia 5.7580 6.7920 6.792
Somalia 4.3470 5.4400 4.347
Somaliland Region 5.0570 5.0570 NA
Somaliland region 5.0570 5.0570 NA
South Africa 4.4590 5.4220 5.213
South Sudan 2.8166 3.8320 NA
Spain 6.3100 6.4910 6.466
Sri Lanka 3.8910 4.4710 3.891
Sudan 4.1390 4.5500 NA
Suriname 6.2690 6.2690 NA
Sweden 7.2840 7.3950 7.345
Switzerland 6.9350 7.5870 6.935
Syrian Arab Republic 3.0060 3.4620 NA
Taiwan, Province of China 6.2980 6.6690 6.669
Tajikistan 4.7860 5.5557 5.411
Tanzania, United Republic of 3.2310 3.8000 3.800
Thailand 5.8430 6.4740 6.222
Togo 2.8390 4.3150 4.315
Trinidad and Tobago 5.9050 6.1920 5.905
Tunisia 4.3922 5.0450 4.552
Turkmenistan 5.0660 5.8220 NA
Türkiye 4.6140 5.5000 5.262
Uganda 3.7390 4.6360 4.461
Ukraine 4.0960 5.0840 4.680
United Arab Emirates 6.5610 6.9010 6.759
United Kingdom of Great Britain and Northern Ireland 6.7140 7.1900 6.728
United States of America 6.7240 7.1190 6.724
Uruguay 6.2930 6.6610 6.661
Uzbekistan 5.9710 6.2576 6.193
Venezuela, Bolivarian Republic of 4.7070 6.8100 5.683
Viet Nam 5.0610 6.3520 6.352
Yemen 3.3550 4.1970 3.561
Zambia 3.5020 5.1290 3.912
Zimbabwe 2.9950 4.6100 3.396

13.3 User interface design - Coordinated aspatial visualizations - Combining Choropleth Map, Region Ridge Plots and Country-level Score Summary Table

This module introduces an integrated user interface for exploring happiness data through aspatial visualizations. It enables users to examine both regional and country-level patterns in happiness scores using ridge plots and summary tables.

Note
  • Input: A selectedInput() for year selection (2014-2024) and pickerInput() for choosing one or more regions.

  • Output: Three coordinated aspatial view including:

    • Region Ridge Plot: Shows score distributions across continents.

    • Country Ridge Plot: Compares national-level score distributions.

    • Summary Table: Displays each country’s min, max, average, and 2024 score with a bullet chart comparing 2024 to historical average.

  • UI Components Used:

    • selectInput() for year selection, and pickerInput() for region filtering,sidebarPanel() for input controls and mainPanel() for visual outputs,tmapOutput(), plotOutput(), and gt_output() to render the visualizations,fluidRow() and column() ensure a responsive, side-by-side layout for clear comparison and user interaction.

Code
library(shiny); library(tmap); library(tmaptools); library(sf); library(dplyr); library(ggplot2); library(ggridges); library(gt); library(gtExtras); library(shinyWidgets)

# Load data
happiness <- read.csv("data/world_happiness.csv")
world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

ui <- fluidPage(
  titlePanel("🌍 World Happiness Explorer"),
  sidebarLayout(
    sidebarPanel(
      width = 3,
      selectInput("year", "Select Year:", choices = sort(unique(happiness$year)), selected = 2024),
      pickerInput(
        inputId = "region",
        label = "Search and Select Region(s):",
        choices = unique(happiness$region),
        selected = unique(happiness$region),
        options = list(`actions-box` = TRUE, `live-search` = TRUE),
        multiple = TRUE
      )
    ),

    mainPanel(
      width = 9,
      fluidRow(
        column(6, tmapOutput("map", height = "400px")),
        column(6, plotOutput("regionPlot", height = "400px"))
      ),
      fluidRow(
        column(12,
               div(style = "overflow-x: auto;",
                   plotOutput("countryPlot", height = "1000px", width = "1500px", inline = TRUE)
               )
        )
      ),
      fluidRow(
        column(12,
               gt_output("summaryTable")
        )
      )
    )
  )
)

server <- function(input, output, session) {

  filtered_data <- reactiveVal()

  observeEvent({input$year; input$region}, {
    new_data <- happiness %>%
      filter(year == input$year, region %in% input$region)
    filtered_data(new_data)
  }, ignoreInit = TRUE)

  output$map <- renderTmap({
    tmap_mode("view")
    happiness_latest <- filtered_data()
    world_happy_latest <- left_join(world, happiness_latest, by = c("name" = "country")) %>%
      filter(!is.na(ladder_score))

    tm_shape(world_happy_latest) +
      tm_polygons(
        col = "ladder_score",
        palette = "YlGnBu",
        id = "name",
        title = paste("Happiness Score (", input$year, ")", sep = "")
      )
  })

  output$regionPlot <- renderPlot({
    df <- filtered_data()
    req(nrow(df) > 0)
    ggplot(df, aes(x = ladder_score, y = region, fill = region)) +
      geom_density_ridges(alpha = 0.7, scale = 1.2) +
      theme_minimal() +
      labs(title = paste("Distribution by Region (", input$year, ")", sep = ""),
           x = "Happiness Score (Ladder Score)",
           y = "Region")
  })

  output$countryPlot <- renderPlot({
    df <- filtered_data()
    req(nrow(df) > 0)
    df$country <- factor(df$country, levels = sort(unique(df$country)))

    ggplot(df, aes(x = ladder_score, y = country, fill = country)) +
      geom_density_ridges(alpha = 0.7, scale = 1.2, stat = "binline", bins = 30) +
      theme_minimal(base_size = 14) +
      labs(title = paste("Distribution of Happiness Scores by Country (", input$year, ")", sep = ""),
           x = "Happiness Score (Ladder Score)",
           y = "Country") +
      theme(legend.position = "none",
            plot.margin = unit(c(10, 20, 10, 20), "pt"),
            plot.title = element_text(size = 16, face = "bold"))
  })

  output$summaryTable <- render_gt({
    df <- happiness %>% filter(region %in% input$region)

    summary_df <- df %>%
      group_by(country) %>%
      summarise(
        MIN = min(ladder_score, na.rm = TRUE),
        MAX = max(ladder_score, na.rm = TRUE),
        AVERAGE = mean(ladder_score, na.rm = TRUE)
      )

    latest_scores <- df %>%
      filter(year == max(year)) %>%
      select(country, ACTUAL = ladder_score)

    combined_df <- summary_df %>%
      left_join(latest_scores, by = "country") %>%
      arrange(country) %>%
      mutate(bullet_chart = ACTUAL) %>%
      select(country, MIN, MAX, AVERAGE, ACTUAL, bullet_chart)

    combined_df %>%
      gt() %>%
      cols_label(
        country = "COUNTRY",
        MIN = "Min Score\n(2014–24)",
        MAX = "Max Score\n(2014–24)",
        AVERAGE = "Avg Score\n(2014–24)",
        ACTUAL = "2024\nScore",
        bullet_chart = "2024 vs Avg"
      ) %>%
      gt_plt_bullet(column = bullet_chart, target = AVERAGE, palette = c("lightblue", "black")) %>%
      tab_header(title = "Happiness Score Dashboard: 2014–2024")
  })
}

shinyApp(ui, server)

Shiny applications not supported in static R Markdown documents